CasaTrade Elasticsearch 장애 시 검색 fallback 설계

CasaTrade Elasticsearch 장애 시 검색 fallback 설계

한눈에 보기

Elasticsearch가 느리거나 중단됐을 때 모든 검색을 DB의 %LIKE%로 넘기면 검색 API는 살아나도 DB가 연쇄 장애를 일으킬 수 있다. 쿼리 종류별로 허용 가능한 대체 기능을 정의하고, 짧은 timeout과 circuit breaker로 장애를 격리하며, 응답에는 degraded와 결과 출처를 표시해야 한다. 검색 엔진이 회복된 뒤에는 단순 health check를 넘어 색인 최신성도 확인한다.

예시 코드에 대하여

CasaTrade의 카탈로그·가격 검색 경계를 배경으로 하지만 아래 인덱스, 쿼리, timeout과 코드는 설명용 재구성 예시다. 실제 운영 구성이나 상품 데이터는 사용하지 않았다.

목차

검색 엔진 장애가 상품 API 장애로 번지는 과정

상품 상세 API가 비슷한 상품, 검색 추천과 가격 evidence를 한 응답에 모두 포함하면 Elasticsearch 장애가 핵심 조회까지 막을 수 있다.

flowchart LR
    C[Client] --> A[Product API]
    A --> D[Primary DB]
    A --> E[Elasticsearch]
    E -->|timeout| A
    A -->|connection 대기| P[Worker Pool 고갈]
    P --> X[전체 API 지연]

한 요청이 검색 timeout을 10초 기다리고 트래픽이 100개 쌓이면 애플리케이션 connection과 메모리가 점유된다. 사용자는 상품 기본 정보조차 보지 못한다.

첫 질문은 “Elasticsearch를 어떻게 살릴까”가 아니라 “검색이 없어도 어떤 사용자 작업을 계속 제공할 수 있는가”다.

모든 검색 기능을 한 fallback으로 묶지 않는다.

fallback은 같은 품질의 대체제가 아니다

fallback은 정상 경로보다 기능이 제한된 degraded mode다. 이를 숨기면 사용자는 검색 결과가 없는 것을 “상품이 존재하지 않는다”로 오해한다.

정상 경로 fallback 잃는 기능
형태소·동의어 검색 DB prefix 오타, 동의어, relevance
복합 필터·facet 최근 캐시 최신성, 전체 범위
이미지 유사도 사용자가 텍스트 입력 이미지 기반 후보
가격 evidence 검색 마지막 성공 snapshot 신규 거래 반영
자동완성 인기 검색어 캐시 개인화와 새 상품

fallback 결과를 정상 결과와 같은 source=search로 반환하면 품질 저하율을 측정할 수 없다. 결과 객체에 실행 경로와 제한을 넣는다.

interface SearchMeta {
  mode: "primary" | "fallback" | "unavailable";
  source: "elasticsearch" | "database_prefix" | "cache";
  degradedReasons: string[];
  resultAsOf?: string;
}

쿼리 종류별로 대체 경로 정하기

검색 요청을 먼저 분류한다.

type SearchIntent =
  | { kind: "exact_code"; code: string }
  | { kind: "prefix"; normalizedName: string }
  | { kind: "full_text"; query: string }
  | { kind: "image_similarity"; assetId: string }
  | { kind: "browse_category"; categoryId: number };
intent ES 장애 시 정책
exact_code DB unique lookup
prefix 길이와 결과 수를 제한한 DB prefix
full_text 최근 cache, 없으면 기능 제한 안내
image_similarity 텍스트 검색 유도, 자동 DB 전환 금지
browse_category DB의 인덱스된 목록

사용자 입력을 그대로 %query%로 만드는 것은 인덱스를 활용하기 어렵고 wildcard 문자를 의도치 않게 해석할 수 있다. prefix fallback은 정규화된 접두 검색만 허용한다.

SELECT product_id, display_name, thumbnail_key
FROM catalog_product
WHERE normalized_name >= :prefix
  AND normalized_name < :prefix_upper_bound
  AND status = 'ACTIVE'
ORDER BY normalized_name, product_id
LIMIT 20;

또는 DB와 collation 특성에 맞는 LIKE 'prefix%'를 사용하고 EXPLAIN으로 확인한다. 관련 내용은 LIKE 검색이 인덱스를 타는 조건과 연결된다.

짧은 timeout과 circuit breaker로 격리하기

fallback이 있어도 primary timeout을 오래 기다리면 응답은 여전히 느리다.

const result = await searchEngine.search(query, {
  timeoutMs: 450,
  signal: requestSignal,
});

timeout 숫자는 예시다. 기능의 전체 지연 예산에서 네트워크, fallback과 응답 직렬화 시간을 빼고 정한다.

circuit breaker는 연속 실패 중인 검색 엔진을 매 요청이 다시 두드리지 않게 한다.

stateDiagram-v2
    [*] --> Closed
    Closed --> Open: 실패율·지연 임계 초과
    Open --> HalfOpen: cooldown 경과
    HalfOpen --> Closed: 제한 probe 성공
    HalfOpen --> Open: probe 실패
async function searchWithBreaker(query: SearchQuery) {
  return searchBreaker.execute(
    () => elasticsearch.search(query),
    () => fallbackRouter.search(query),
  );
}

인스턴스마다 breaker가 따로 있으면 일부 인스턴스가 계속 probe를 보낼 수 있다. 먼저 로컬 breaker로 단순하게 시작하되 전체 의존성 지표와 load balancer 상태를 함께 본다.

DB fallback에 반드시 예산을 두기

검색 트래픽을 DB로 모두 옮기면 평소보다 훨씬 많은 쿼리가 primary DB에 몰린다.

async function guardedDatabaseFallback(intent: SearchIntent) {
  if (!fallbackBudget.tryAcquire()) {
    return unavailable("FALLBACK_CAPACITY_EXHAUSTED");
  }

  try {
    return await withTimeout(
      databaseSearch.findPrefix(intent, { limit: 20 }),
      180,
    );
  } finally {
    fallbackBudget.release();
  }
}

fallback의 목적은 검색 API 성공률 100%가 아니라 전체 시스템을 보호하며 최소 기능을 제공하는 것이다. DB가 위험해지면 검색 기능을 제한하고 상품 ID 직접 조회를 유지하는 편이 낫다.

연쇄 장애

primary가 실패한 모든 요청을 용량이 더 작은 fallback으로 보내면 장애를 다른 시스템으로 옮길 뿐이다.

degraded 상태를 응답에 표시하기

{
  "items": [
    {
      "id": 42,
      "name": "Example Camera"
    }
  ],
  "meta": {
    "mode": "fallback",
    "source": "database_prefix",
    "degradedReasons": ["semantic_search_unavailable"],
    "resultAsOf": "2026-08-04T02:10:00Z"
  }
}

클라이언트는 다음처럼 표현할 수 있다.

현재 고급 검색이 일시적으로 제한되어 상품명 앞부분이 일치하는 결과만 보여 드립니다.

items: []만 반환하면 정말 결과가 없는지 검색을 수행하지 못한 것인지 알 수 없다. HTTP 200으로 제한 결과를 반환하더라도 meta는 필요하다. 어떤 의도도 대체할 수 없다면 503과 재시도 가능 정보를 반환할 수 있다.

가격 추정에 fallback evidence를 사용했다면 가격 결과에도 source와 기준 시각을 전달한다. 오래된 snapshot을 현재 가격처럼 표시하지 않는다.

캐시를 fallback으로 사용할 때

캐시는 DB 부하가 적고 빠르지만 stale data와 권한 문제가 있다.

interface CachedSearchResult {
  normalizedQueryHash: string;
  indexGeneration: string;
  items: SearchItem[];
  createdAt: string;
  expiresAt: string;
  visibilityScope: string;
}

캐시 키에는 정규화 쿼리, 필터, 정렬, locale과 권한 범위가 필요하다. 관리자 전용 상품 결과를 공개 사용자 캐시에 재사용하면 안 된다.

stale-while-error 정책을 사용할 수 있다.

fresh TTL: 정상 경로에서 바로 사용
stale TTL: 검색 장애일 때만 제한적으로 사용
hard expiry: 어떤 상황에서도 사용하지 않음

응답에 resultAsOf를 포함하고, 재고·판매 가능 여부처럼 오래된 값이 위험한 필드는 DB에서 다시 검증하거나 결과에서 제외한다.

쓰기와 색인 지연을 별도로 다루기

Elasticsearch가 정상이어도 DB에 방금 만든 상품이 아직 색인되지 않았을 수 있다. 이는 availability 장애가 아니라 consistency 문제다.

sequenceDiagram
    participant A as Admin API
    participant D as DB
    participant O as Outbox
    participant I as Indexer
    participant E as Elasticsearch
    A->>D: 상품 저장
    A->>O: ProductChanged
    O->>I: 이벤트 전달
    I->>E: index document
    E-->>I: generation 기록

관리자가 방금 만든 상품을 즉시 찾아야 한다면 write 응답의 ID로 DB 상세를 보여 주거나 짧은 read-your-write overlay를 둔다. 모든 사용자 검색을 DB fallback으로 전환할 필요는 없다.

색인 문서에는 source DB version을 넣고, update가 순서 뒤바뀜으로 오래된 문서를 덮지 않게 외부 버전이나 조건부 갱신을 사용한다.

회복은 health check 한 번으로 끝나지 않는다

클러스터가 200을 반환한다고 검색 결과가 최신인 것은 아니다. 장애 동안 outbox backlog가 쌓였거나 일부 shard가 복구 중일 수 있다.

회복 조건:

Half-open 상태에서 일부 트래픽만 primary로 보내고 성공을 확인한 뒤 점진적으로 복구한다.

function choosePrimaryTrafficRatio(health: SearchHealth) {
  if (health.state === "open") return 0;
  if (health.state === "half_open") return 0.02;
  return 1;
}

장애 중 캐시된 fallback 결과도 primary 회복 후 자연히 만료되거나 generation 변화로 무효화해야 한다.

TypeScript 형태의 재구성 예시

type SearchResult =
  | {
      status: "ok";
      items: SearchItem[];
      meta: SearchMeta;
    }
  | {
      status: "unavailable";
      items: [];
      meta: SearchMeta;
      retryAfterSeconds: number;
    };

class ResilientCatalogSearch {
  constructor(
    private readonly primary: CatalogSearchPort,
    private readonly fallback: SearchFallbackRouter,
    private readonly breaker: CircuitBreaker,
    private readonly metrics: SearchMetrics,
  ) {}

  async search(
    intent: SearchIntent,
    signal: AbortSignal,
  ): Promise<SearchResult> {
    if (!this.breaker.allowsRequest()) {
      return this.runFallback(intent, "circuit_open");
    }

    try {
      const items = await withTimeout(
        this.primary.search(intent, { signal }),
        450,
        signal,
      );
      this.breaker.recordSuccess();
      return {
        status: "ok",
        items,
        meta: {
          mode: "primary",
          source: "elasticsearch",
          degradedReasons: [],
        },
      };
    } catch (error) {
      const kind = classifySearchFailure(error);
      this.breaker.recordFailure(kind);
      this.metrics.primaryFailure(kind);
      return this.runFallback(intent, kind);
    }
  }

  private async runFallback(
    intent: SearchIntent,
    reason: string,
  ): Promise<SearchResult> {
    const result = await this.fallback.search(intent);
    this.metrics.fallback(result.meta.source, reason);
    return result;
  }
}

모든 오류를 breaker 실패로 세지 않는다. 잘못된 사용자 쿼리의 400이나 존재하지 않는 인덱스 설정 오류는 다른 방식으로 처리한다. timeout, connection 오류와 5xx 등 의존성 건강 신호를 분류한다.

fallback router:

class SearchFallbackRouter {
  async search(intent: SearchIntent): Promise<SearchResult> {
    switch (intent.kind) {
      case "exact_code":
        return dbExactSearch(intent);
      case "prefix":
        return guardedDatabaseFallback(intent);
      case "browse_category":
        return cachedCategoryOrDatabase(intent);
      case "full_text":
        return staleCacheOrUnavailable(intent);
      case "image_similarity":
        return unavailableResult(
          "IMAGE_SEARCH_TEMPORARILY_UNAVAILABLE",
        );
    }
  }
}

재시도와 동시 요청 폭주 막기

사용자 요청 안에서 Elasticsearch를 여러 번 즉시 재시도하면 지연과 부하가 증가한다. 읽기 요청이라도 남은 deadline과 오류 종류를 기준으로 제한한다.

동일 쿼리가 동시에 몰릴 때 single-flight로 primary 호출이나 cache refresh를 합칠 수 있다.

const result = await searchFlights.run(
  stableSearchKey(intent),
  () => resilientSearch.search(intent, signal),
);

클라이언트, API gateway, 애플리케이션과 SDK가 각각 재시도하면 실제 호출 수가 곱해진다. 한 계층을 책임자로 정하고 지수 백오프와 jitter를 적용한다. 재시도에 지수 백오프와 지터가 필요한 이유Circuit Breaker로 연쇄 장애 줄이기가 연결되는 지점이다.

테스트할 장애 시나리오

시나리오 기대 결과
primary가 timeout 허용 intent만 fallback
circuit open primary 호출 없이 즉시 분기
DB fallback 포화 검색 제한, DB 보호
exact code 조회 DB 인덱스로 정확히 반환
image similarity 장애 prefix 결과로 위장하지 않음
stale cache 존재 기준 시각과 함께 반환
권한 범위가 다른 cache 재사용 금지
ES 회복, indexer backlog 큼 half-open 유지
오래된 색인 이벤트가 늦게 도착 새 version 덮어쓰기 금지
모든 의존성 실패 명시적 unavailable

장애 테스트에서는 단순 mock exception뿐 아니라 느린 응답, 부분 shard 실패, malformed response와 connection pool 고갈을 주입한다.

운영 지표와 Runbook

지표 확인할 문제
primary success·timeout rate ES 가용성과 지연
circuit state by instance open·half-open 분포
fallback rate by intent 기능별 품질 저하
DB fallback concurrency 연쇄 장애 위험
stale cache age 결과 최신성
unavailable rate 대체 불가능한 검색
indexing backlog age 회복 후 데이터 지연
DB-index version lag 일관성 차이
degraded response CTR 제한 결과가 실제로 유용한지

Runbook에는 breaker를 강제로 닫는 버튼보다 먼저 다음을 넣는다.

  1. search cluster와 index alias 상태 확인
  2. 애플리케이션 fallback과 DB 부하 확인
  3. indexer backlog와 outbox 확인
  4. 대표 query 결과 비교
  5. half-open traffic의 오류·지연 확인
  6. 점진적 정상 전환

수동으로 breaker를 닫아 장애 트래픽을 한꺼번에 되돌리지 않는다.

결론

Elasticsearch 장애에 catch를 추가하고 DB 검색을 호출하는 것만으로는 복원력이 생기지 않는다. 검색 트래픽이 primary DB로 몰리면 핵심 상품 조회까지 함께 무너질 수 있고, 이미지 유사도처럼 의미 있는 대체가 없는 기능도 있다.

핵심은 검색 intent별로 허용 가능한 최소 기능을 정하고, 짧은 timeout과 circuit breaker로 primary 장애를 격리하며, fallback 용량과 최신성에 명시적인 예산을 두는 것이다.

응답에는 degraded 상태와 source, 기준 시각을 표시해야 한다. 회복도 health endpoint 성공이 아니라 인덱스 alias, backlog, version lag와 대표 쿼리까지 확인하고 점진적으로 진행해야 한다. 그래야 fallback이 검색 장애를 숨기는 코드가 아니라 전체 시스템을 보호하는 운영 모드가 된다.

관련 노트